cubeapi: key the rate limiter on the validated identity, not an unvalidated header - #1380
cubeapi: key the rate limiter on the validated identity, not an unvalidated header#1380dwin-gharibi wants to merge 7 commits into
Conversation
| .and_then(|v| v.to_str().ok()) | ||
| .unwrap_or("anonymous") | ||
| .to_string(); | ||
| .extensions() |
There was a problem hiding this comment.
Stale doc comments now that the key comes from the validated identity, not the header. The module doc above still reads: "Per-API-key token bucket rate limiter middleware. Reads the X-API-Key header and checks the shared governor limiter." That's no longer accurate — this now keys on the RateLimitIdentity extension published by unified_auth (which is itself keyed on the validated credential). Same for AppState::rate_limiter's "Per-API-key rate limiter" field doc. Worth a one-line update so the docs don't mislead a future reader into re-adding header-based keying.
Review — #1380: cubeapi: key the rate limiter on the validated identity, not an unvalidated headerAI-generated review (static analysis; the PR head was not built or executed in this session — no blocking issues found, findings are low-severity nits). Verdict: approveThis is a well-scoped and correctly-implemented fix for a real security/correctness bug. The limiter previously bucketed on the raw I verified the invariants the change relies on:
Findings (inline)
Design considerations (no action required)
Verification notesReviewed against the base-branch workspace ( |
|
LGTM. I've verified the fix empirically with governor 0.6.3 — the rotating-header bypass is closed (keying on the validated identity), and the GC doesn't reset active buckets. The remaining points are non-blocking; I'd just suggest adding a GC regression test when you have a chance. Thanks! |
…e validated identity, not an unvalidated header Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…key and correct the limiter docs Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
… router level Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…irst and correct the per-identity docs Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…isolation Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…ad of storing raw credentials Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
…s and reclaiming idle keys Signed-off-by: Dwin Gharibi <dwin.gharibi@email.kntu.ac.ir>
f05fdd4 to
5f48af7
Compare
|
Everything is fine now @liciazhu. |
Closes #1379.
Motivation
The limiter bucketed on the raw
X-API-Keyheader. Becauseextract_credentialprefersAuthorization: Bearer, that header is never validated when a Bearer token is present — but it stillchose the bucket. Rotating it therefore gave a fresh full-quota bucket per request and the limit never
applied. Clients sending no
X-API-Keyall shared a single"anonymous"bucket, so in callback(multi-tenant) mode any one tenant could starve the rest. And the keyed state store was never reclaimed.
What this changes
1.
middleware/auth.rs— the auth middleware publishes the identity it validated. A newRateLimitIdentity(String)is inserted into the request extensions once the credential has been accepted,in both modes:
The identity is prefixed (
bearer:/apikey:) so the two credential kinds cannot collide.2.
middleware/rate_limit.rs— the limiter reads that extension instead of a header, falling back toa single
"unauthenticated"bucket if it is somehow absent. In practice it never is:rate_limitis onlyever layered together with
unified_auth, andunified_authruns first.3.
middleware/auth.rs— the API key comparison is now constant-time.provided != expected_keyshort-circuits at the first differing byte, which leaks the key one byte at a time to a patient attacker.
Replaced with a length-check plus an XOR-accumulate loop. CubeOps already does this
(
subtle.ConstantTimeCompare), so the two services now agree.4.
state.rs— a background task callsrate_limiter.retain_recent()every 60s, so the DashMap nolonger grows without bound.
No comment changes.
Testing
Re-ran the probe that originally demonstrated the bypass, against the real binary with
CUBE_API_KEY=supersecret --rate-limit-per-sec 3, 30 requests per case:X-API-KeyX-API-KeyonlyCase B is the fix. Case C confirms API-key clients are still limited, and case A that Bearer clients are
too — all three shapes now behave the same.
CubeAPI's own suite:
CI gates checked locally:
cargo fmt --check— clean (fmt-check).cargo build— clean.cargo clippy --all-targets— no new warnings. The twofield_reassign_with_defaulthits reported inauth.rsare the pre-existing ones in that file's test module; their line numbers moved because thischange inserts code above them.
Behaviour change worth noting
Bearer clients are now limited per token rather than sharing one global bucket. That is the intended
behaviour, but it does mean a deployment that was unknowingly relying on the shared-bucket accounting will
see different 429 patterns. Concretely: a single Bearer client that previously consumed the shared quota
now gets its own, so aggregate throughput across many Bearer clients goes up, while an individual abusive
client is now actually constrained.
Not fixed here
extract_credentialmatches theBearerscheme case-sensitively, so a spec-compliantauthorization: bearer <token>is rejected (RFC 7235 §2.1 makes the scheme case-insensitive). I confirmedit still reproduces on this branch (5/5 requests → 401) and left it alone: it is a separate defect with its
own issue, and mixing an interop fix into a rate-limiting fix would muddy both.